3.3-WebComponent
Create by fall on 17 Oct 2021 Recently revised in 06 May 2023
Web Component
Web Component 意为 Web 组件系统,使用 JS 创建和 React、Vue 的组件类似可复用的组件。实现比如生命周期,CSS 样式隔离,组合继承等方法。并且可以创造一个用户定制的 HTML 标签,拥有 HTML 元素的所有属性,可以在任何支持的浏览器中通过引入这些脚本,就可以调用这些组件。
Web Components 由三项主要技术组成,它们可以一起使用来创建封装功能的定制元素,这些元素可以在你喜欢的任何地方重用,不必担心代码冲突。
- Custom element(自定义元素):一组 JavaScript API,允许您自定义元素及其行为(创建
<my-component>
),然后可以在您的用户界面中按照需要使用它们。 - Shadow DOM(影子 DOM):一组 JavaScript API,允许元素的功能私有(隔离 DOM 和 CSS 样式)通过将封装的“影子”DOM 树附加到元素(单独的 DOM 作用域)进行控制其关联的功能。这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。
- HTML template(HTML 模板):
<template>
和<slot>
元素使您可以编写不在页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。
对比框架
目前的前端框架具有数据绑定、状态管理和相当标准化的代码库等功能所带来的额外价值。以及使用习惯。
框架的数据绑定会更方便,在 Web Component 中,也允许直接用数据来设置 web component 的属性。并且可以通过 attributeChangedCallback 来监听这些属性的改变。前端框架提供了一个标准的代码基准,可以使团队中的每一个新人从一开始就熟悉这些代码基准。
现在 web component 已经得到了广泛的支持,可以与框架一较高下:
- 不需要框架
- 性能更好,生成代码更少
- 易于继承,不需要编译
- 真正的局部 CSS 作用域(Shadow DOM)
- 只有 HTML,CSS,JavaScript
但,框架和 Web Component 不一定只有对立,肯定是相互融合,最终呈现出更繁荣的生态环境。
基本使用
使用
创建模板,该内容放置在 index.html 的 body 标签内
<template id="span-group" >
<span>没什么的,对吧?</span>
</template>
添加该脚本
window.onload = function () {
class MyElement extends HTMLElement {
// 当元素被创建时,会调用 constrcutor 函数,更多内容请查看生命周期
constructor() {
// 必须首先调用 super 方法
super()
// 元素的功能代码写在这里
const dom = document.querySelector('#span-group')
const content = dom.content.cloneNode(true) // 使用模板的克隆,其他位置引用时不会相互影响
const text = this.getAttribute('text') // 获取当前组件上的 text 属性
content.querySelector('span').innerText = text
this.appendChild(content) // 在当前 DOM(MyElement) 中插入这些内容
}
}
// 最后向 html 中注册组件
// 为了避免和 native 的标签冲突,必须用 `-` 进行链接
customElements.define('my-component', MyElement) // 分别输入想在 dom 中使用的名称和对应的类
}
使用
<my-component></my-component>
<my-component text="有问题啊"></my-component>
<my-component text="没有问题啊"></my-component>
<my-component></my-component>
状态管理
数据监听和事件触发
class MyElement extends HTMLElement{
// 定义被监视的属性
static get observedAttributes(){
return ['foo', 'bar'] // 这些属性都在 标签上被调用
}
attributeChangedCallback(attr, oldVal, newVal){
// 每当将属性添加到 observedAttributes 的数组中时
switch(attr){
case 'foo':
console.log('foo changed, new value is',newVal)
break;
case 'bar':
}
}
}
给每一个需要被监听的属性添加get函数
get text () {
return this.getAttribute('text');
}
再定义属性的set函数
set text (value) {
this.setAttribute('text', value);
}
现在我们就能直接给组件对象定义属性并赋值,而不需要定义在组件DOM元素上。如下
const mybutton = document.querySelector('my-button');
mybutton.text = 'Hi!';
自定义方法
在你的元素上定义的任何方法,都会成为其公共 JavaScript 的一部分。
class MyElement extends HTMLElement {
// ...
doSomething() {
// do something in this method
}
}
const element = document.querySelector('my-element');
element.doSomething();
事件
自定义元素的标准事件(如鼠标和键盘事件)默认情况下将会从 Shadow DOM 中冒泡,事件的 target 属性还是会指向自定义元素本身。如果你想找出事件实际来自 Shadow DOM 中的哪个元素,可以调用 event.composedPath()
来检索事件经过的节点数组。
有两种发送方式
- 自定义组件上添加自定义方法监听 DOM 元素事件,在需要的地方调用组件的自定义方法
- 自定义事件使用 CustomEvent 从自定义元素中抛出任何你想要的事件。
class MyButton extends HTMLElement{
constructor(){
super() // 同 react 都需要在最开始调用 super
const template = document.getElementById('my_button')
const content = template.content.cloneNode(true)
const button = content.querySelector("#btn")
button.innerText = this.getAttributes('text')
// button 被点击时,触发点击事件
button.addEventListener('click',ev=>{
// 把点击事件挂载到该组件上,点击时向外传递参数
this.onClick('f,m,l')
// 因为 HTMLElement 同样继承了 EventTarget,所以也有 dispatchEvent 方法
// this.dispatchEvent(
// new CustomEvent('onClick', {
// detail: 'Hello'
// })
// )
})
}
connectedCallback() {
this.dispatchEvent(new CustomEvent('custom', {
detail: {message: 'a custom event'}
}));
}
}
// 在需要的时候,调用组件的自定义方法
document.querySelector('my-button').onClick = value => {
console.log(value)
}
class MyElement extends HTMLElement {
// ...
}
// on the outside
document.querySelector('my-element').addEventListener('custom', e => console.log('message from event:', e.detail.message));
如果一个事件从 Shadow DOM 的节点抛出而不是自定义元素本身,默认不会从 ShadowDOM 上冒泡,除非它使用了 composition: true
。
class MyElement extends HTMLElement {
// ...
connectedCallback() {
this.container = this.shadowRoot.querySelector('#container');
// dispatchEvent is now called on this.container instead of this
this.container.dispatchEvent(new CustomEvent('custom', {
detail: {message: 'a custom event'},
composed: true // without composed: true this event will not bubble out of Shadow DOM
}));
}
}
生命周期
生命周期执行顺序
类进行创建的时候,执行 constructor
当元素被插入 DOM 时,会调用 connectCallback
从文档中删除时执行 disconnectedCallback
,在关闭浏览器时不会调用
当web component被移动到新文档时执行 adoptedCallback
。
被监听的属性发生变化时执行 attributeChangedCallback
constructor -> attributeChangedCallback -> connectedCallback -> disconnectedCallback
生命周期回调
class myElement extends HTMLElement{
adoptedCallback(){
// 当元素通过调用 document.adoptNode(element) 被采用到文档时将会被调用
}
static observedAttributes(){ // 被监视的属性
return ['foo', 'bar'] // 这些属性都在 标签上被调用
}
attributeChangedCallback(attr, oldVal, newVal){
// 每当将属性添加到 observedAttributes 的数组中时
switch(attr){
case 'foo':
break;
case 'bar':
}
}
connectCallback(){
// 当这个元素被插入DOM树的时候将会触发当前方法
// 对元素进行设置
// 此时可以确定所有的属性和子元素都已经可用的办法
}
disconnectCallback(){
// 当元素从DOM中被移除的时候,会被调用
}
}
理论上通过序列化可以将复杂值传递给属性,但是这样会影响性能,并且你可以直接调用组件的方法,所以不需要这样做。
attributeChangedCallback 要在 connectedCallback 之前执行
因为 web 组件上的属性,被插入DOM前就应该初始化配置了。因此 attributeChangedCallback 要在 connectedCallback 之前执行。 这意味着你需要根据某些属性的值,在 Shadow DOM 中配置节点,那么你需要在构造函数中引用这些节点,而不是在 connectedCallback 中引用它们。
Shadow DOM
Shadow DOM,可以将自定义元素的 HTML 和 CSS 完全封装在组件内。整个自定义内容将以单个的 HTML 标签出现在文档的 DOM 树中(不用再担心内部 id 的冲突)。内部的元素将始终不会影响到它外部的元素(除了 :focus-within
)。
比如网页中原生的 <video>
元素也使用了 Shadow DOM。它作为一个单独的标签使用,因为在它的 Shadow DOM 中,包含了一系列的按钮和其他控制器,所以能够拥有播放和暂停视频的控件。
要在 Chrome 中显示 Shadow DOM,进入开发者工具中的 Preferences 中,选中 Show user agent Shadow DOM。你就可以看到该元素的 Shadow DOM了。
一些特有的术语需要了解
- Shadow host:常规 DOM 节点,Shadow DOM 会被附加到这个节点上。
- Shadow tree:Shadow DOM 内部的 DOM 结构
- Shadow boundary:Shadow DOM 结束的地方,也是常规 DOM 开始的地方
- Shadow root:Shadow tree 的根节点
开启 Shadow DOM
class MyElement extends HTMLElement {
// 当元素被创建时,会调用 constrcutor 函数,更多内容请查看生命周期
constructor() {
// 必须先调用 super()
super()
const template = document.querySelector('template#span-group')
const content = template.content.cloneNode(true) // 使用模板的克隆,其他位置引用时不会相互影响
// 获取 shadowRoot
const shadowRoot = this.attachShadow({ mode: "open" });
// 在 shadow DOM 中插入内容,插入的内容会应用插槽
shadowRoot.appendChild(content)
// 通过 shadowRoot 去查找元素
shadowRoot.querySelector('.pink')
}
}
样式
样式需要搭配 Shadow DOM 实现样式隔离,使组件内所有的 CSS 都只应用于组件本身。
可以将样式书写在 template 中,以保证样式仅应用于 button 上。
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `<p>Hello world</p>`;
这定义了一个带 mode: open 的 Shadow root,这意味着可以再开发者工具找到它并与之交互,配置暴露出的 CSS 属性,监听抛出的事件。同样也可以定义 mode:closed,会得到与之相反的表现。
自定义元素默认使用 display:inline
,可以使用 :host
选择器对组件本身进行样式设置。但是,从外部定义在组件本身的样式优先于使用 :host
样式。
<template id="mybutton">
<style>
:host {
display: block;
}
button {
color:blue;
border: 0;
border-radius: 5px;
background-color: brown;
}
</style>
<button id="btn">My Button</button>
</template>
样式也支持标签的写法
// 该样式会覆盖 Shadow DOM 中定义的 :host
my-element {
display: inline-block;
}
// 当然也允许你根据上下文的样式化。
// 通过 disabled 的 attribute 来改变组件的背景是否为灰色
:host([disabled]) {
opacity: 0.5;
}
// 自定义元素会从周围的 CSS 中继承一些属性,例如颜色和字体等
// 如果想清空组件的初始状态并且将组件内的所有 CSS 都设置为默认的初始值,使用:
:host {
all: initial;
}
样式一般通过暴露一个变量名称,让用户通过 DOM 上的节点去自定义这个样式,不应该在外部进行更改。
// 比如,你设置默认背景颜色为 #ffffff
:host {
--background-color: #ffffff;
}
// 现在用户可以在组件的外部设置它的背景颜色
// 假设 shadow dom 中有一个节点为 div#container
#container {
background-color: var(--background-color);
}
// 也可以通过标签进行修改
my-element {
--background-color: #00cc00;
}
通过提供局部的 CSS、HTML,Shadow DOM 将标记和样式捆绑到自己的组件内,而不需要任何工具和命名约定。你再也不用担心新的 class 或 id 会与现有的任何一个冲突。
除此之外,还可以通过 CSS 变量设置 web 组件的内部样式,还可以通过插槽将 HTML 注入到 Web Components中。
插槽
插槽需要搭配 Shadow DOM 才能实现插入对应的位置
<slot>
(插槽),将使用 Web Components 的包裹的内容添加到组件内。
<template id="span-group">
<span>文档默认内容,对吧?</span>
<p>
<!-- 创建一个插槽,会将 name 为 my-text 的内容展示出来 -->
<slot name="my-text">My default text</slot>
</p>
<slot name="image">
<div>默认的内容</div>
</slot>
</template>
创建对象时需要使用 shadow DOM
constructor() {
super()
const template = document.querySelector('template#span-group')
const content = template.content.cloneNode(true) // 使用深度克隆,其他位置引用时不会相互影响
const shadowRoot = this.attachShadow({ mode: "open" });
shadowRoot.appendChild(content)
}
使用
<my-component>
<ul slot="my-text">
<!-- 对应名称的 my-text 会出现在对应的 html 中 -->
<li>Let's have some different text!</li>
<li>In a list!</li>
</ul>
<div>滚</div>
</my-component>
用户提供的标记称为 light DOM。使用 Shadow DOM 会将 light DOM 和 Shadow DOM 合并成为一个新的 DOM 树。
通过 slot 在 Shadow DOM 中展示的元素被称为分发节点。这些组件被插入前的样式也将会被用于他们插入后。在 Shadow DOM 中,分发节点可以通过 ::sloted()
来获取额外的样式
::slotted(img) {
float: left;
}
::sloted()
可以接受任何有效的 CSS 选择器,但它只能选择顶级节点,例如 ::slotedd(section img)
的情况,将不会作用于 this content
<image-gallery>
<section slot="image">
<img src="foo.jpg">
</section>
</image-gallery>
你可以通过 JavaScript 与 slots 进行交互去监测哪个节点被分发到哪个 slot,哪些 slot 被插入了元素,以及 slotchange 事件。
要找出哪些元素已经被分发给对应的 slots 可以使用 slot.assignedNodes()
如果你还想查看 slot 的默认内容,你可以使用 slot.assignedNodes({flatten: true})
要找出哪些 slot 被分发的元素,可以使用 element.assignedSlot
当 slot 内的节点发生改变,即添加或删除节点时,将会出发 slotchange
事件。要注意的是,只有当slot节点自身改变才会触发,而这些 slot 节点的子节点并不会触发。
slot.addEventListener('slotchange', e => {
const changedSlot = e.target;
console.log(changedSlot.assignedNodes());
});
在元素第一次初始化时,Chrome 会触发 slotchange 事件,而 Safari 和 Firefox 则不会。
API
Web Component 相关的 API
customElements
customElements.define()
customElements.get()
const MyElement = customElement.get('my-element')
// get 后,可以直接获取到 customElement 的类,实例化后即可添加到 DOM 中
document.body.appendChild(new MyElement)
customElements.upgrade()
customElements.whenDefined()
customElements.whenDefined('my-element')
.then(() => {
})
shadowRoot
进阶使用
模板元素
除了使用 this.shadowRoot.innerHTML
来向一个元素的 shadow root 添加 HTML,你也可以使用 <template>
。template 保存HTML 供以后使用。它不会被渲染,并只有确保内容是有效的才会进行解析。template 中的 JavaScript 不会被执行,也不会获取任何外部资源,默认情况下它是隐藏的。
当一个 web component 需要根据不同的情况来渲染不同的标记时,可以用不同的模板来完成:
class MyElement extends HTMLElement {
constructor() {
const shadowRoot = this.attachShadow({mode: 'open'});
// 将两个 template 放在 shadow root 内,但这两个模板都是隐藏的,最开始只渲染了 div#container
this.shadowRoot.innerHTML = `
<template id="view1">
<p>This is view 1</p>
</template>
<template id="view1">
<p>This is view 1</p>
</template>
<div id="container">
<p>This is the container</p>
</div>`
}
connectedCallback() {
// 获取模板内容
const content = this.shadowRoot.querySelector('#view1').cloneNode(true);
this.container = this.shadowRoot.querySelector('#container');
// 将模板内的内容插入到模板内
this.container.appendChild(content.content);
}
}
扩展原生元素
自定义元素允许使用扩展原生内置元素,支持增强已经存在的 HTML 元素,例如 <img>
和 <buttons>
。
扩展现有 HTML 元素的会继承了元素的所有属性和方法。这允许对现有元素进行逐步的增强。这意味着即使在不支持自定义元素的浏览器中,它仍是可用的。它只会降级到默认的内置行为。而如果它是一个全新的 HTML 标签,那它将会完全无法使用。
例如,我们想要增强一个 <button>
。
class WordCount extends HTMLButtonElement {
constructor() {
super();
// button 中默认内容为正确,点击后切换内容
this.textContent = '正确'
this.onclick = () => {
if (this.innerText === '正确') {
this.textContent = '错误'
} else {
this.textContent = '正确'
}
}
}
}
customElements.define('toggle-button', WordCount, { extends: 'button' });
我们的 web component 可以扩展 HTMLButtonElement,比 HTMLElement 更具体。当使用 customElements.define()
的时候还需要添加一个额外的参数 {extends: 'button'}
来表示我们的类扩展的是 <button>
元素。但是这是必要的,因为多个元素可能共享一个 DOM 接口。例如 <q>
和 <blockquote>
都共享 HTMLQuoteElement 接口。
这个增强后的 button 可以通过 is 属性来被使用
<button is="my-button">
现在它将被我们的 MyElement 类增加,如果它加载在一个不支持自定义元素的浏览器中,它将降级到一个标准的按钮,真正的渐进式增强。
注意,在扩展现有元素时,部分元素不能使用 Shadow DOM。这只是一种扩展原生 HTML 元素的方法,它继承了所有现有的属性、方法和事件,并提供了额外的功能。当然可以在组件中修改元素的 DOM 和 CSS,但是尝试创建一个 Shadow root 将会抛出一个错误。
扩展内置元素的另一个好处就是,这些元素也可以应用于子元素被限制的情况。例如 thead 元素只允许 tr 作为其子元素,因此<awesome-tr>
元素将呈现无效标记。这种情况下,我们可以拓展内置的 tr 元素。并像这样使用它:
<table>
<thead>
<tr is="awesome-tr"></tr>
</thead>
</table>
封装
自定义按钮
将组件进行封装,使组件可以直接通过 script 调用
const template = document.createElement('template');
template.innerHTML = `
<style>
button {
width: 60px;
height: 30px;
cursor: pointer;
color: blue;
border: 0;
border-radius: 5px;
background-color: #F0F0F0;
}
</style>
<div>
<button id="btn">Add</button>
<p id="message"><slot name="my-text">My Default Text</slot></p>
<ul id="text-list"></ul>
</div>
`;
const Texts = [
'My lady, Hello!',
'BuiBuiBui',
'BiliBili',
'Haiwei is NO.1'
]
class MyButton extends HTMLElement {
constructor () {
super()
const content = template.content.cloneNode(true);
const button = content.querySelector('#btn');
const textList = content.querySelector('#text-list');
this.$button = button;
this.$message = content.querySelector('#message');
button.addEventListener('click', (evt) => {
const li = document.createElement('li');
li.innerText = Texts[Math.floor(Math.random() * 4)];
textList.appendChild(li);
this.dispatchEvent(
new CustomEvent('onClick', {
detail: 'Hello fom within the Custom Element'
})
)
})
this.attachShadow({ mode: 'open' }).appendChild(content);
}
get text () {
return this.getAttribute('text');
}
set text (value) {
this.setAttribute('text', value);
}
static get observedAttributes() {
return ['text'];
}
attributeChangedCallback(name, oldVal, newVal) {
this.render();
}
render() {
this.$message.innerText = this.text;
}
}
window.customElements.define('my-button', MyButton)
在 index.html
中直接引入即可使用
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Component</title>
<script src="./MyButton.js"></script>
</head>
<body>
<my-button>
<p slot="my-text">Another text from outside</p>
</my-button>
</body>
<script>
const mybutton = document.querySelector('my-button');
mybutton.addEventListener('onClick', (value) => {
console.log(value)
mybutton.text = value.detail
});
</script>
</html>
图片懒加载
这是一个图片懒加载的组件 lazy-img 自定义组件主要以元素 img 标签进行实现
<lazy-img
src="path/to/image.jpg"
width="480"
height="320"
delay="500"
margin="0px"></lazy-img>
测试
与为 Angular 和 React 这样的框架编写测试相比,测试 web components 既简单又直接。不需要转换或者复杂的设置,只需要创建元素,并将其添加到 DOM 中并运行测试。 这里有一个使用 Mocha 的测试
import 'path/to/my-element.js';
describe('my-element', () => {
let element;
beforeEach(() => {
element = document.createElement('my-element');
document.body.appendChild(element);
});
afterEach(() => {
document.body.removeChild(element);
});
it('should test my-element', () => {
// run your test here
});
});
在这里,第一行引入了 my-element.js 文件,该文件将我们的 web component 通过 ES6 模块对外暴露。这意味着我们测试文件也需要作为一个 ES6 模块加载到浏览器中。这需要以下的index.html能够在浏览器中运行测试。除了Mocha,这个设置还加载了WebcomponentsJS polyfill,Chai用于断言,以及Sinon用于监听和模拟。
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../node_modules/mocha/mocha.css">
<script src="../node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
<script src="../node_modules/sinon/pkg/sinon.js"></script>
<script src="../node_modules/chai/chai.js"></script>
<script src="../node_modules/mocha/mocha.js"></script>
<script>
window.assert = chai.assert;
mocha.setup('bdd');
</script>
<script type="module" src="path/to/my-element.test.js"></script>
<script type="module">
mocha.run();
</script>
</head>
<body>
<div id="mocha"></div>
</body>
</html>
在加载完所需的 scripts 后,我们暴露 chai.assert 作为一个全局变量,因此我们可以在测试中简易的使用 assert(),并设置 Mocha 来使用 BDD 接口。然后加载测试文件,并调用 mocha.run()
运行测试。
请注意,在使用ES6模块化时,还需要将 mocha.run()
放在type="module"
的script中。因为 ES6 模块在默认情况下是延迟执行的。如果 mocha.run() 放在一个常规的 script 标签中,他将会在加载 my-element.test.js 之前执行。
参考文章
作者 | 文章名称 |
---|---|
CodeX | WebComponent是个什么东西? |
山竹和阿瓜 | Web Component可以取代你的前端框架吗? |
MDN 官方文档 | 使用 templates and slots |